Skip to content

S07-03 网络编程-项目:多用户即时通信系统 ​

[TOC]

基于 Java SE 网络编程实现的多用户即时通信系统,采用 C/S 架构,覆盖客户端与服务端两侧。

技术栈:Java 8+、TCP Socket 长连接、对象流(ObjectInputStream/ObjectOutputStream)传输 Serializable 对象、多线程并发、HashMap 模拟数据库。

项目概述 ​

项目背景 ​

本项目以经典即时通信软件 QQ 为原型,使用 Java SE 基础网络编程技术(Socket + 多线程 + 对象流)实现一个支持多用户同时在线的命令行即时通信系统。项目旨在综合运用网络编程、多线程并发、对象序列化等知识点,作为 Java 网络编程阶段的综合实战。

目标用户 ​

  • 学习 Java 网络编程的开发者,作为教学与练习项目。
  • 需要在局域网/本机进行多用户文字通信与文件传输的轻量场景。

核心功能清单 ​

序号功能说明
1用户登录客户端输入账号/密码,服务端校验并返回结果
2拉取在线用户列表客户端请求,服务端返回当前在线用户
3无异常退出客户端与服务端均可正常退出,释放资源
4私聊点对点文字通信
5群聊向所有在线用户广播消息
6发文件点对点文件传输
7服务器推送新闻服务端群发系统消息

扩展功能 ​

  1. 离线留言:用户未在线时,消息暂存服务端,该用户登录后推送。

  2. 离线发文件:用户未在线时,文件暂存服务端,该用户登录后推送。

技术栈 ​

类别技术
语言Java 8+
网络通信java.net.Socket / ServerSocket(TCP 长连接)
数据传输ObjectInputStream / ObjectOutputStream 传输 Serializable 对象
并发模型多线程(服务端每客户端一线程;客户端一个收消息守护线程)
数据存储HashMap 内存模拟(用户库、在线用户表)
界面控制台菜单(无 GUI)

非目标 ​

  • 不实现图形界面(GUI)。
  • 不使用真实数据库(MySQL 等)。
  • 不引入 Netty 等高级网络框架。
  • 不实现消息加密与持久化历史记录。

系统架构 ​

C/S 架构总览 ​

通信模型 ​

  • 传输层:TCP 长连接。客户端启动后与服务端建立一次 Socket 连接,整个会话期间保持该连接不断开(退出时关闭)。
  • 应用层:所有通信以 Message 对象为载体,通过对象流读写。一条请求/响应即一次 writeObject / readObject。
  • 方向:客户端 → 服务端为请求;服务端 → 客户端为响应或推送。

线程模型 ​

  • 服务端:主线程循环 accept(),每接入一个客户端就创建一个 ClientConnectionThread 专门处理该客户端的消息;另起一个 SendNewsToAllService 线程负责推送新闻。
  • 客户端:主线程负责菜单交互与发送消息;登录成功后启动一个 ServerConnectionThread 守护线程,持续接收服务端推送/转发的消息。

模块划分 ​

模块包职责
公共模块com.qq.common定义通信协议(Message、MessageType、User),客户端与服务端共用
服务端模块com.qq.server监听连接、校验登录、消息转发、在线用户管理、新闻推送
客户端模块com.qq.client菜单交互、发送各类请求、接收并展示消息、文件收发

通信协议设计 ​

消息载体 ​

所有通信数据封装在 Message 类中,该类实现 Serializable,通过对象流传输。

消息类型常量表 ​

常量名值方向含义
MESSAGE_LOGIN1C→S登录请求
MESSAGE_LOGIN_SUCCEED2S→C登录成功
MESSAGE_LOGIN_FAIL3S→C登录失败
MESSAGE_COMM_MES4C→S→C私聊消息
MESSAGE_GET_ONLINE_FRIEND5C→S请求在线用户列表
MESSAGE_RET_ONLINE_FRIEND6S→C返回在线用户列表
MESSAGE_CLIENT_EXIT7C→S客户端退出
MESSAGE_TO_ALL_MES8C→S→C群聊消息
MESSAGE_FILE_MES9C→S→C文件传输
MESSAGE_SERVER_MES10S→C服务端推送新闻

各操作报文格式 ​

下表描述各操作中 Message 对象的字段填充约定。未列出字段为空/默认。

登录 ​

阶段typesendercontentgetter
请求MESSAGE_LOGIN(1)用户 ID密码—
成功响应MESSAGE_LOGIN_SUCCEED(2)———
失败响应MESSAGE_LOGIN_FAIL(3)———

说明:登录请求将密码放在 content 字段传输(教学项目简化处理)。

拉取在线用户列表 ​

阶段typecontent
请求MESSAGE_GET_ONLINE_FRIEND(5)—
响应MESSAGE_RET_ONLINE_FRIEND(6)"100 200 300"(空格分隔的用户 ID)

私聊 ​

阶段typesendergettercontentsendTime
请求/转发MESSAGE_COMM_MES(4)发送者 ID接收者 ID文字内容时间字符串

群聊 ​

阶段typesendergettercontentsendTime
请求/转发MESSAGE_TO_ALL_MES(8)发送者 ID"all"文字内容时间字符串

文件传输 ​

阶段typesendergettersrcdestfileBytes
请求/转发MESSAGE_FILE_MES(9)发送者 ID接收者 ID源文件路径目标保存路径文件字节数组

客户端退出 ​

阶段type
请求MESSAGE_CLIENT_EXIT(7)

服务器推送新闻 ​

阶段typesendercontentsendTime
推送MESSAGE_SERVER_MES(10)"服务端"新闻内容时间字符串

公共模块设计 ​

包名:com.qq.common,客户端与服务端共用,需保证两侧类定义与 serialVersionUID 完全一致。

MessageType ​

消息类型常量定义,以 interface 形式提供静态常量。

java
package com.qq.common;

/**
 * 消息类型常量定义
 */
public interface MessageType {
  int MESSAGE_LOGIN = 1;              // 登录请求
  int MESSAGE_LOGIN_SUCCEED = 2;      // 登录成功
  int MESSAGE_LOGIN_FAIL = 3;         // 登录失败
  int MESSAGE_COMM_MES = 4;           // 私聊消息
  int MESSAGE_GET_ONLINE_FRIEND = 5;  // 请求在线用户列表
  int MESSAGE_RET_ONLINE_FRIEND = 6;  // 返回在线用户列表
  int MESSAGE_CLIENT_EXIT = 7;        // 客户端退出
  int MESSAGE_TO_ALL_MES = 8;         // 群聊消息
  int MESSAGE_FILE_MES = 9;           // 文件传输
  int MESSAGE_SERVER_MES = 10;        // 服务端推送新闻
}

Message ​

通信数据载体,实现 Serializable。

java
package com.qq.common;

import java.io.Serializable;

/**
 * 通信消息封装类,客户端与服务端之间传输的统一数据格式
 */
public class Message implements Serializable {
  private static final long serialVersionUID = 1L;

  private int type;        // 消息类型,取值见 MessageType
  private String sender;   // 发送者ID
  private String getter;   // 接收者ID(群聊为 "all")
  private String content;  // 消息内容(登录时存密码、在线列表时存用户ID串)
  private String sendTime; // 发送时间
  private String src;      // 文件传输:源文件路径
  private String dest;     // 文件传输:目标保存路径
  private byte[] fileBytes;// 文件传输:文件字节数组

  // 省略所有字段的 getter / setter
  // int getType() / void setType(int)
  // String getSender() / void setSender(String)
  // String getGetter() / void setGetter(String)
  // String getContent() / void setContent(String)
  // String getSendTime() / void setSendTime(String)
  // String getSrc() / void setSrc(String)
  // String getDest() / void setDest(String)
  // byte[] getFileBytes() / void setFileBytes(byte[])
}

User ​

用户信息载体,登录时客户端发送给服务端校验,实现 Serializable。

java
package com.qq.common;

import java.io.Serializable;

/**
 * 用户信息类
 */
public class User implements Serializable {
  private static final long serialVersionUID = 1L;

  private String userId;  // 用户ID
  private String passwd;  // 密码

  // 构造方法:User() / User(String userId, String passwd)
  // String getUserId() / void setUserId(String)
  // String getPasswd() / void setPasswd(String)
}

服务端设计 ​

包名:com.qq.server

类总览 ​

类父类职责
QQServer—服务端主程序,监听端口、校验登录、创建并管理客户端线程
ClientConnectionThreadThread与单个客户端通信的线程,读取并分发消息
ClientConnectionThreadManager—静态工具类,管理所有在线客户端线程集合
SendNewsToAllServiceThread新闻推送线程,读取控制台输入并群发
QQServerUser—模拟用户数据库

QQServer ​

java
package com.qq.server;

import com.qq.common.*;

/**
 * 服务端主程序,监听端口、校验登录、创建并管理客户端线程
 */
public class QQServer {
  private ServerSocket ss = null;
  private static final int PORT = 9999;

  public QQServer() {
    try {
      // 启动新闻推送线程
      new Thread(new SendNewsToAllService()).start();
      ss = new ServerSocket(PORT);

      while (true) {
        Socket socket = ss.accept();           // 阻塞等待连接
        ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
        User user = (User) ois.readObject();    // 读取登录用户信息
        ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());

        Message message = new Message();
        if (checkUser(user.getUserId(), user.getPasswd())) { // 校验
          message.setType(MessageType.MESSAGE_LOGIN_SUCCEED);
          oos.writeObject(message);

          // 创建并启动该客户端的专属线程
          ClientConnectionThread cct =
            new ClientConnectionThread(socket, user.getUserId());
          cct.start();
          ClientConnectionThreadManager.addClientThread(user.getUserId(), cct);
        } else {
          message.setType(MessageType.MESSAGE_LOGIN_FAIL);
          oos.writeObject(message);
          socket.close();
        }
      }
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      // 服务端关闭时资源释放(见 7.3 无异常退出)
    }
  }

  /** 校验用户:内部调用 QQServerUser 模拟库 */
  private boolean checkUser(String userId, String passwd) {
    // 调用 QQServerUser.checkUser(userId, passwd)
  }

  public static void main(String[] args) {
    new QQServer();
  }
}

ClientConnectionThread ​

java
package com.qq.server;

import com.qq.common.*;

/**
 * 与单个客户端通信的线程,读取并分发消息
 */
public class ClientConnectionThread extends Thread {
  private Socket socket;
  private String userId; // 关联的客户端用户ID

  public ClientConnectionThread(Socket socket, String userId) {
    this.socket = socket;
    this.userId = userId;
  }

  @Override
  public void run() {
    while (true) {                                  // 持续读取客户端消息
      try {
        ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
        Message message = (Message) ois.readObject();

        switch (message.getType()) {
          case MessageType.MESSAGE_GET_ONLINE_FRIEND:
            // 返回在线用户列表(见 7.2)
            break;
          case MessageType.MESSAGE_COMM_MES:
            // 私聊转发(见 7.4)
            break;
          case MessageType.MESSAGE_TO_ALL_MES:
            // 群聊转发(见 7.5)
            break;
          case MessageType.MESSAGE_FILE_MES:
            // 文件转发(见 7.6)
            break;
          case MessageType.MESSAGE_CLIENT_EXIT:
            // 客户端退出处理(见 7.3)
            return;                        // 结束本线程
          // ...
        }
      } catch (Exception e) {
        e.printStackTrace();
        return;
      }
    }
  }

  public Socket getSocket() { return socket; }
}

ClientConnectionThreadManager ​

java
package com.qq.server;

import java.util.HashMap;
import java.util.Iterator;

/**
 * 静态工具类,管理所有在线客户端线程集合
 */
public class ClientConnectionThreadManager {
  private static HashMap<String, ClientConnectionThread> hm = new HashMap<>();

  public static void addClientThread(String userId, ClientConnectionThread cct) {
    hm.put(userId, cct);
  }

  public static ClientConnectionThread getClientThread(String userId) {
    return hm.get(userId);
  }

  /** 返回所有在线用户ID,空格分隔 */
  public static String getOnlineUserList() {
    Iterator<String> it = hm.keySet().iterator();
    StringBuilder sb = new StringBuilder();
    while (it.hasNext()) {
      sb.append(it.next()).append(" ");
    }
    return sb.toString();
  }

  public static void removeClientThread(String userId) {
    hm.remove(userId);
  }

  public static HashMap<String, ClientConnectionThread> getHm() {
    return hm;
  }
}

SendNewsToAllService ​

java
package com.qq.server;

import com.qq.common.*;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.io.*;

/**
 * 新闻推送线程,读取控制台输入并群发
 */
public class SendNewsToAllService implements Runnable {
  @Override
  public void run() {
    while (true) {
      // 读取服务端控制台输入的新闻内容
      // 构造 MESSAGE_SERVER_MES 消息
      // 遍历 ClientConnectionThreadManager.getHm() 所有线程,逐个 getSocket() 写出消息
      // (见 7.7 服务器推送新闻)
    }
  }
}

QQServerUser ​

java
package com.qq.server;

import com.qq.common.User;
import java.util.HashMap;

/**
 * 模拟用户数据库
 */
public class QQServerUser {
  private static HashMap<String, User> validUsers = new HashMap<>();

  static {
    validUsers.put("100", new User("100", "123456"));
    validUsers.put("200", new User("200", "123456"));
    validUsers.put("300", new User("300", "123456"));
    validUsers.put("至尊宝", new User("至尊宝", "123456"));
    validUsers.put("紫霞仙子", new User("紫霞仙子", "123456"));
    validUsers.put("菩提老祖", new User("菩提老祖", "123456"));
  }

  /** 校验用户是否存在且密码正确 */
  public boolean checkUser(String userId, String passwd) {
    User user = validUsers.get(userId);
    if (user != null && passwd.equals(user.getPasswd())) {
      return true;
    }
    return false;
  }
}

默认账号:100 / 200 / 300 / 至尊宝 / 紫霞仙子 / 菩提老祖,密码统一 123456,用于多客户端联调。

客户端设计 ​

包名:com.qq.client

类总览 ​

类父类职责
QQView—客户端主程序,控制台菜单 UI 与主流程控制
UserClientService—登录、登出、拉取在线用户列表的业务逻辑
MessageClientService—私聊、群聊业务逻辑
FileClientService—文件发送业务逻辑
ServerConnectionThreadThread守护线程,持续接收服务端消息并展示
ServerConnectionThreadManager—静态工具类,管理客户端与服务端的连接线程

QQView ​

  • mainMenu

    image-20260805223741085

  • secondaryMenu

    image-20260805225306494

java
package com.qq.client;

import com.qq.client.service.UserClientService;

/**
 * 客户端主程序,控制台菜单 UI 与主流程控制
 */
public class QQView {
    private boolean loop = true;          // 控制主菜单循环
    private String key = "";              // 接收用户输入
    private UserClientService userClientService = new UserClientService();
    private MessageClientService messageClientService = new MessageClientService();
    private FileClientService fileClientService = new FileClientService();

    public static void main(String[] args) {
        new QQView().mainMenu();
    }

    /** 一级菜单:登录 */
    private void mainMenu() {
        while (loop) {
            // 输出登录菜单:系统名称、登录、退出
            // 选择"登录系统" → 调用 userClientService.checkUser() → 登录成功进入二级菜单 secondaryMenu()
            // 选择"退出系统" → loop = false
        }
    }

    /** 二级菜单:功能操作 */
    private void secondaryMenu() {
        while (loop) {
            // 1 拉取在线用户列表 → userClientService.onlineFriendList()
            // 2 群发消息 → messageClientService.sendMessageToAll()
            // 3 私聊消息 → messageClientService.sendMessageToOne()
            // 4 发送文件 → fileClientService.sendFile()
            // 9 退出系统 → userClientService.logout()
        }
    }
}

UserClientService ​

java
package com.qq.client;

import com.qq.common.*;

/**
 * 登录、登出、拉取在线用户列表的业务逻辑
 */
public class UserClientService {
  private User user = new User();
  private Socket socket;

  /** 用户登录 */
  public boolean checkUser(String userId, String pwd) {
    // 1. 创建 Socket 连接服务端 PORT=9999
    // 2. 发送 User 对象
    // 3. 接收 Message 响应
    //    - MESSAGE_LOGIN_SUCCEED:启动 ServerConnectionThread 守护线程,返回 true
    //    - MESSAGE_LOGIN_FAIL:关闭 socket,返回 false
  }

  /** 拉取在线用户列表 */
  public void onlineFriendList() {
    // 构造 MESSAGE_GET_ONLINE_FRIEND 发送
    // 守护线程接收 MESSAGE_RET_ONLINE_FRIEND 并打印(见 7.2)
  }

  /** 退出登录 */
  public void logout() {
    // 构造 MESSAGE_CLIENT_EXIT 发送
    // 关闭 socket,结束守护线程
  }
}

MessageClientService ​

java
package com.qq.client;

import com.qq.common.*;

/**
 * 私聊、群聊业务逻辑
 */
public class MessageClientService {
  /** 私聊 */
  public void sendMessageToOne(String senderId, String getterId, String content) {
    // 构造 MESSAGE_COMM_MES 消息,填充 sender/getter/content/sendTime
    // 通过当前连接线程的 socket 发送(见 7.4)
  }

  /** 群聊 */
  public void sendMessageToAll(String senderId, String content) {
    // 构造 MESSAGE_TO_ALL_MES 消息,getter="all",填充 sender/content/sendTime
    // 发送(见 7.5)
  }
}

FileClientService ​

java
package com.qq.client;

import com.qq.common.*;

/**
 * 文件发送业务逻辑
 */
public class FileClientService {
  /** 发送文件 */
  public void sendFile(String senderId, String getterId, String src, String dest) {
    // 1. 读取 src 文件为 byte[]
    // 2. 构造 MESSAGE_FILE_MES 消息,填充 sender/getter/src/dest/fileBytes
    // 3. 发送(见 7.6)
  }
}

ServerConnectionThread ​

java
package com.qq.client;

import com.qq.common.*;

/**
 * 客户端连接服务端的线程类
 */
public class ServerConnectionThread extends Thread {
  private Socket socket;

  public ServerConnectionThread(Socket socket) {
    this.socket = socket;
  }

  @Override
  public void run() {
    while (true) {                              // 持续接收服务端消息
      try {
        ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
        Message message = (Message) ois.readObject(); // 阻塞读取服务器返回的消息

        switch (message.getType()) {
          case MessageType.MESSAGE_RET_ONLINE_FRIEND:
            // 打印在线用户列表(见 7.2)
            break;
          case MessageType.MESSAGE_COMM_MES:
            // 显示私聊消息(见 7.4)
            break;
          case MessageType.MESSAGE_TO_ALL_MES:
            // 显示群聊消息(见 7.5)
            break;
          case MessageType.MESSAGE_FILE_MES:
            // 保存文件到 dest(见 7.6)
            break;
          case MessageType.MESSAGE_SERVER_MES:
            // 显示服务端推送新闻(见 7.7)
            break;
          // ...
        }
      } catch (Exception e) {
        e.printStackTrace();
        return;
      }
    }
  }

  public Socket getSocket() { return socket; }
}

ServerConnectionThreadManager ​

java
package com.qq.client;

import com.qq.client.service.ServerConnectionThread;

import java.util.HashMap;

/**
 * 管理客户端连接服务端的线程的类
 */
public class ServerConnectionThreadManager {
    private static HashMap<String, ServerConnectionThread> hm = new HashMap<>();

    public static void addServerConnectionThread(String userId, ServerConnectionThread ccst) {
        hm.put(userId, ccst);
    }

    public static ServerConnectionThread getServerConnectionThread(String userId) {
        return hm.get(userId);
    }

    public static void removeServerConnectionThread(String userId) {
        hm.remove(userId);
    }
}

功能模块详细设计 ​

用户登录 ​

关键代码骨架(客户端发送登录):

java
// UserClientService.checkUser()
user.setUserId(userId);
user.setPasswd(pwd);
socket = new Socket("127.0.0.1", 9999);
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(user);                                  // 发送 User

ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message msg = (Message) ois.readObject();               // 读取响应
if (msg.getType() == MessageType.MESSAGE_LOGIN_SUCCEED) {
  ServerConnectionThread ccst = new ServerConnectionThread(socket);
  ccst.start();
  ServerConnectionThreadManager.addServerConnectionThread(userId, ccst);
  return true;
} else {
  socket.close();
  return false;
}

拉取在线用户列表 ​

服务端处理骨架:

java
case MessageType.MESSAGE_GET_ONLINE_FRIEND:
  Message msg = new Message();
  msg.setType(MessageType.MESSAGE_RET_ONLINE_FRIEND);
  msg.setContent(ClientConnectionThreadManager.getOnlineUserList());
  ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
  oos.writeObject(msg);
  break;

无异常退出 ​

客户端退出:

  • 二级菜单选择"退出系统" → UserClientService.logout() 发送 MESSAGE_CLIENT_EXIT → 关闭 socket。

  • 守护线程因 readObject 抛异常而自然结束。

    java
    public void logout() {
      Message message = new Message();
      message.setType(MessageType.MESSAGE_CLIENT_EXIT);
      message.setSender(user.getUserId());
      ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
      oos.writeObject(message);
      socket.close();                                      // 关闭连接
    }

服务端退出处理:

  • 收到 MESSAGE_CLIENT_EXIT 后,从 ClientConnectionThreadManager 移除该用户、关闭对应 socket、结束该客户端线程。

    java
    case MessageType.MESSAGE_CLIENT_EXIT:
      ClientConnectionThreadManager.removeClientThread(userId);
      socket.close();
      return;                                              // 结束本线程 run()

服务端主程序退出:监听循环可设计条件退出,finally 中关闭 ServerSocket,并遍历关闭所有客户端 socket 与推送线程。

私聊 ​

服务端转发骨架:

java
case MessageType.MESSAGE_COMM_MES:
  String getterId = message.getGetter();
  ClientConnectionThread cct = ClientConnectionThreadManager.getClientThread(getterId);
  if (cct != null) {
    ObjectOutputStream oos = new ObjectOutputStream(cct.getSocket().getOutputStream());
    oos.writeObject(message);                        // 原样转发
  }
  // 否则接收者离线(扩展功能处理)
  break;

客户端接收骨架:

java
case MessageType.MESSAGE_COMM_MES:
  System.out.println("\n" + message.getSender() + " 对 "
    + message.getGetter() + " 说: " + message.getContent()
    + "  时间:" + message.getSendTime());
  break;

群聊 ​

服务端群发骨架:

java
case MessageType.MESSAGE_TO_ALL_MES:
  HashMap<String, ClientConnectionThread> all = ClientConnectionThreadManager.getHm();
  for (String uid : all.keySet()) {
    // 可选择不回发给发送者自身;默认群发含自身
    ObjectOutputStream oos = new ObjectOutputStream(
      all.get(uid).getSocket().getOutputStream());
    oos.writeObject(message);
  }
  break;

发文件 ​

客户端发送骨架:

java
public void sendFile(String senderId, String getterId, String src, String dest) {
  // 读取文件
  FileInputStream fis = new FileInputStream(src);
  byte[] fileBytes = new byte[(int) new File(src).length()];
  fis.read(fileBytes);
  fis.close();

  Message message = new Message();
  message.setType(MessageType.MESSAGE_FILE_MES);
  message.setSender(senderId);
  message.setGetter(getterId);
  message.setSrc(src);
  message.setDest(dest);
  message.setFileBytes(fileBytes);

  // 通过当前连接线程的 socket 发送
  ObjectOutputStream oos = new ObjectOutputStream(
    ServerConnectionThreadManager.getServerConnectionThread(senderId)
      .getSocket().getOutputStream());
  oos.writeObject(message);
}

客户端接收骨架:

java
case MessageType.MESSAGE_FILE_MES:
  System.out.println("\n" + message.getSender() + " 给 "
    + message.getGetter() + " 发文件: " + message.getSrc()
    + " 到我的电脑目录 " + message.getDest());
  FileOutputStream fos = new FileOutputStream(message.getDest(), false);
  fos.write(message.getFileBytes());
  fos.close();
  break;

服务器推送新闻 ​

推送线程骨架:

java
public void run() {
  Scanner scanner = new Scanner(System.in);
  while (true) {
    System.out.print("请输入服务器推送新闻/公告(exit 退出推送):");
    String news = scanner.next();
    if ("exit".equals(news)) break;

    Message message = new Message();
    message.setType(MessageType.MESSAGE_SERVER_MES);
    message.setSender("服务端");
    message.setContent(news);
    message.setSendTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
      .format(new Date()));

    HashMap<String, ClientConnectionThread> all = ClientConnectionThreadManager.getHm();
    for (String uid : all.keySet()) {
      ObjectOutputStream oos = new ObjectOutputStream(
        all.get(uid).getSocket().getOutputStream());
      oos.writeObject(message);
    }
  }
}

数据结构设计 ​

模拟用户库 ​

java
HashMap<String, User> validUsers;   // 服务端,key=userId
  • 启动时静态初始化,内置若干测试账号。
  • 仅用于登录校验,不持久化。

在线用户表 ​

java
HashMap<String, ClientConnectionThread> hm;  // key=userId, value=该客户端通信线程
  • 登录成功时 put,退出时 remove。
  • 提供 getOnlineUserList() 拼接所有 key 返回。
  • 并发注意:HashMap 非线程安全。教学项目中消息量低、并发写冲突概率小,可保持 HashMap;若需加固,可替换为 ConcurrentHashMap,或在 ClientConnectionThreadManager 方法上加 synchronized。

客户端连接管理表 ​

java
HashMap<String, ServerConnectionThread> hm;  // key=userId, value=与服务端的连接线程
  • 单客户端通常仅一个连接,通过 userId 取到对应线程的 socket 发送消息。

Message 字段矩阵 ​

字段类型登录在线列表私聊群聊文件推送退出
typeint✅✅✅✅✅✅✅
senderString✅—✅✅✅✅✅
getterString——✅"all"✅——
contentString密码用户 ID 串文字文字—新闻—
sendTimeString——✅✅—✅—
srcString————✅——
destString————✅——
fileBytesbyte[]————✅——

项目目录结构 ​

QQ/
├── 开发文档.md
└── src/
  └── com/
    └── qq/
      ├── common/
      │   ├── Message.java
      │   ├── MessageType.java
      │   └── User.java
      ├── server/
      │   ├── QQServer.java
      │   ├── QQServerUser.java
      │   ├── ClientConnectionThread.java
      │   ├── ClientConnectionThreadManager.java
      │   └── SendNewsToAllService.java
      └── client/
        ├── QQView.java
        ├── UserClientService.java
        ├── MessageClientService.java
        ├── FileClientService.java
        ├── ServerConnectionThread.java
        └── ServerConnectionThreadManager.java

包职责一览 ​

包类数职责
com.qq.common3通信协议与数据载体,双端共享
com.qq.server5服务端监听、校验、转发、管理、推送
com.qq.client6客户端菜单、业务服务、守护线程、连接管理

扩展功能设计 ​

离线留言 ​

场景:私聊时接收者不在线,消息需暂存,待其登录后推送。

设计方案:

  1. 服务端新增 HashMap<String, List<Message>> offlineMessages,key=接收者 ID。

  2. ClientConnectionThread 处理 MESSAGE_COMM_MES 时,若 getClientThread(getter) 为 null(离线),将消息 add 到 offlineMessages.get(getter)。

  3. 客户端登录成功、ClientConnectionThread 启动后,先检查 offlineMessages.get(userId),若有积压,逐条通过该 socket 写出给客户端,然后清空列表。

离线发文件 ​

场景:发文件时接收者不在线,文件需暂存,待其登录后推送。

设计方案:

  1. 服务端新增 HashMap<String, List<Message>> offlineFiles,key=接收者 ID。

  2. 处理 MESSAGE_FILE_MES 时,若接收者离线,将携带 fileBytes 的 Message 存入 offlineFiles。

  3. 接收者登录后,由其线程读取并推送积压文件,随后清空。

  4. 注意:fileBytes 可能较大,大量离线文件会占用服务端内存;教学项目可限定大小或数量。

测试方案 ​

测试环境 ​

  • 编译并运行服务端一次,客户端运行多份(不同账号)模拟多用户。
  • 本机测试:服务端 127.0.0.1:9999,多个客户端窗口分别登录不同账号。

集成测试用例 ​

用例操作预期结果
TC-01 登录成功账号 100/密码 123456 登录返回登录成功,进入二级菜单
TC-02 登录失败账号 100/密码 wrong 登录返回登录失败,提示重新输入
TC-03 拉取在线列表登录后选"拉取在线用户"显示当前所有在线用户 ID
TC-04 私聊A 私聊 B 内容"hello"B 收到"A 对 B 说: hello 时间:..."
TC-05 私聊离线A 私聊不在线的 C不报错(扩展:暂存留言)
TC-06 群聊A 群发"大家好"所有在线用户(含 A)收到消息
TC-07 发文件A 向 B 发 test.txt 到 dest.txtB 侧生成 dest.txt,内容一致
TC-08 客户端退出A 选"退出系统"A 断开、线程结束、不影响其他用户
TC-09 服务端推送服务端输入新闻所有在线客户端收到推送并显示
TC-10 多用户并发3 客户端同时在线相互私聊/群聊消息各自正确送达,无串台

验收标准 ​

  • 七项核心功能全部按预期工作,无异常栈输出。
  • 客户端退出不影响服务端与其他客户端。
  • 文件传输后内容与源文件逐字节一致。

部署与运行 ​

编译 ​

假设源码位于 src/,在项目根目录执行:

bash
javac -d out -sourcepath src src/com/qq/server/QQServer.java
javac -d out -sourcepath src src/com/qq/client/QQView.java

编译产物输出到 out/ 目录。

运行 ​

第一步:启动服务端

bash
java -cp out com.qq.server.QQServer

服务端监听 9999 端口,等待客户端连接。

第二步:启动客户端(可多个)

bash
java -cp out com.qq.client.view.QQView

每开一个窗口即可用一个账号登录。推荐至少开 2-3 个客户端窗口联调。

端口与账号说明 ​

  • 服务端口:9999(如冲突可改 QQServer.PORT 与客户端连接端口)。
  • 测试账号:100 / 200 / 300 / 至尊宝 / 紫霞仙子 / 菩提老祖,密码均 123456。
  • 字符编码:源码与控制台使用 UTF-8;Windows 控制台中文若乱码,运行前执行 chcp 65001。

假设与约定 ​

  • 文档语言为中文;技术路线严格对齐参考笔记(纯 Java SE,无 Netty、无数据库、控制台 UI)。
  • 默认服务端口 9999,字符编码 UTF-8,账号库为硬编码内存数据,重启即重置。
  • 模拟数据库用 HashMap,非线程安全;教学场景可接受,加固时可换 ConcurrentHashMap。
  • 扩展功能(离线留言 / 离线文件)以设计方案形式给出,标注为可选实现,不强制纳入核心交付。
  • Message 的 serialVersionUID 在客户端与服务端必须一致,否则反序列化失败。
  • 文档仅为开发规格说明,不包含可编译的完整 .java 源文件;实现者按本文档类设计与代码骨架补全方法体即可。